Skip to content

feat(Dialog): add unmountOnHide prop#2662

Merged
zernonia merged 14 commits into
unovue:v2from
benjamincanac:feat/dialog-unmount-on-hide
Jun 15, 2026
Merged

feat(Dialog): add unmountOnHide prop#2662
zernonia merged 14 commits into
unovue:v2from
benjamincanac:feat/dialog-unmount-on-hide

Conversation

@benjamincanac

@benjamincanac benjamincanac commented May 27, 2026

Copy link
Copy Markdown
Contributor

Resolves #1727

Adds unmountOnHide to DialogRoot. When false, dialog content and overlay stay in the DOM when closed (hidden via v-show) instead of being unmounted. Useful for SEO and avoiding remounts.

Based on #2494 (credit to @MickL), with fixes for aria-hidden leaking while closed, the present prop leaking to the DOM, scroll lock staying on when hidden, focus restoration when the content stays mounted (modal + non-modal), and a DismissableLayer fix so body pointer-events restore when disableOutsidePointerEvents toggles off without unmounting.

Summary by CodeRabbit

  • New Features

    • Added unmountOnHide to Dialog (default: true); dialog components can now stay mounted but hidden, with visibility controlling accessibility and scroll-lock.
  • Documentation

    • Dialog docs updated to document the new unmountOnHide prop and default.
  • Tests

    • Added tests covering DOM presence, visibility, focus restoration, and accessibility when unmountOnHide is false.
  • Bug Fixes

    • Fixed a documentation comment typo.

Review Change Stack

When set to false, Dialog content and overlay stay in the DOM when
closed (hidden via v-show) instead of being unmounted. Useful for SEO
and performance by avoiding remounts on every open.

Closes unovue#1727
@coderabbitai

coderabbitai Bot commented May 27, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds an unmountOnHide prop (default true) to DialogRoot, provides it via context, and updates Presence-driven rendering across DialogOverlay/DialogContent and modal/non-modal impls so dialogs can be unmounted or hidden; tests and docs updated.

Changes

Dialog unmountOnHide Implementation

Layer / File(s) Summary
Root context and prop definition
packages/core/src/Dialog/DialogRoot.vue
DialogRootProps adds unmountOnHide?: boolean; DialogRootContext includes unmountOnHide: Ref<boolean>; default set to true and provided via context.
Overlay visibility and scroll lock management
packages/core/src/Dialog/DialogOverlay.vue, packages/core/src/Dialog/DialogOverlayImpl.vue
DialogOverlay computes present/force-mount via Presence slot using open and unmountOnHide; DialogOverlayImpl accepts optional present, uses it for v-show and drives body scroll-lock with a watcher.
Content Presence slot and rendering
packages/core/src/Dialog/DialogContent.vue
Refactors Presence to a scoped slot exposing present; modal and non-modal content use v-show controlled by unmountOnHide or slot present and receive present prop.
Modal content accessibility and props
packages/core/src/Dialog/DialogContentModal.vue
Adds present: boolean prop; conditions useHideOthers target on present; strips present before forwarding props; sets :disable-outside-pointer-events from present; watches present to restore focus when it becomes false.
Non-modal content present handling
packages/core/src/Dialog/DialogContentNonModal.vue
Adds present prop; forwards props without it; watches present to restore focus on close and reset outside-interaction tracking.
Implementation minor fix
packages/core/src/Dialog/DialogContentImpl.vue
Fixes a misspelling in the forceMount prop documentation comment.
Unmount-on-hide tests and mocks
packages/core/src/Dialog/Dialog.test.ts
Adds fixtures and test suites for unmountOnHide=false (modal and non-modal), tests DOM persistence when closed, focus restoration, absence of body aria-hidden after close, accessibility checks, and updates mock typings.
Documentation and metadata
docs/content/meta/DialogRoot.md
Adds unmountOnHide prop entry with description and default value; included in the llm-visible props table.

Sequence Diagram

sequenceDiagram
  participant Client
  participant DialogRoot
  participant Presence
  participant DialogContentModal
  participant DialogContentNonModal
  participant DialogOverlayImpl
  Client->>DialogRoot: mount with unmountOnHide (true|false)
  DialogRoot->>Presence: provide :present/:force-mount (computed)
  Presence->>DialogContentModal: present slot value
  Presence->>DialogContentNonModal: present slot value
  Presence->>DialogOverlayImpl: present slot value
  DialogContentModal->>DialogContentModal: use present to set aria-hidden target & pointer events
  DialogContentModal->>DialogContentModal: watch present -> restore focus when false
  DialogOverlayImpl->>DialogOverlayImpl: watch present -> toggle body scroll lock
Loading

🎯 3 (Moderate) | ⏱️ ~20 minutes

🐰 I keep the dialogs in sight,

Hidden with CSS when day turns night,
Unmount or show, the root decides,
Accessibility still abides,
A nimble hop to better tides.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(Dialog): add unmountOnHide prop' is concise and clearly describes the main feature addition—a new prop that controls whether dialog content is unmounted or hidden when closed.
Linked Issues check ✅ Passed The PR fully addresses issue #1727 by implementing the requested unmountOnHide prop to keep dialog content in the DOM using v-show instead of v-if for SEO and performance reasons.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing the unmountOnHide feature across Dialog components, context, documentation, and tests—no unrelated modifications detected.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

Review ran into problems

🔥 Problems

Stopped waiting for pipeline failures after 30000ms. One of your pipelines takes longer than our 30000ms fetch window to run, so review may not consider pipeline-failure results for inline comments if any failures occurred after the fetch window. Increase the timeout if you want to wait longer or run a @coderabbit review after the pipeline has finished.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@benjamincanac benjamincanac marked this pull request as draft May 27, 2026 10:44
@pkg-pr-new

pkg-pr-new Bot commented May 27, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/reka-ui@2662

commit: 515a0b2

@benjamincanac benjamincanac changed the title feat(Dialog): support unmountOnHide feat(Dialog): add unmountOnHide prop May 27, 2026
@benjamincanac benjamincanac marked this pull request as ready for review May 27, 2026 10:49
Also fixes focus restoration when unmountOnHide=false — since FocusScope
never unmounts, close-auto-focus doesn't fire. Added a watcher on present
to manually restore focus to trigger when the dialog hides.
@MickL

MickL commented May 27, 2026

Copy link
Copy Markdown

You dont believe how happy you make me with this PR! Will test soon, hope it gets merged eventually...

…n-hide

# Conflicts:
#	packages/core/src/Dialog/Dialog.test.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/core/src/Dialog/Dialog.test.ts (1)

121-124: ⚡ Quick win

Strengthen the aria-hidden assertion by exercising the open→close cycle.

This test only checks the initial (never-opened) state. The core value of the present-gated useHideOthers is removing aria-hidden from the rest of the page after the dialog has been opened and then closed (while the content stays mounted). Consider opening, then pressing Escape, then asserting body has no aria-hidden to actually cover that path.

💚 Suggested test enhancement
   it('should not apply aria-hidden to body when closed', async () => {
+    await fireEvent.click(trigger.element)
+    await nextTick()
+    await fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
     await nextTick()
     expect(document.body.getAttribute('aria-hidden')).toBeNull()
   })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/Dialog/Dialog.test.ts` around lines 121 - 124, The test
currently only verifies the initial state; update 'should not apply aria-hidden
to body when closed' to exercise the open→close cycle: render/open the Dialog
(use the component's present/open API or simulate the trigger to set
present=true), await nextTick, simulate closing via Escape (dispatch a
KeyboardEvent or use the library's close method), await nextTick again, then
assert document.body.getAttribute('aria-hidden') is null; this will exercise
useHideOthers' present-gated behavior and confirm aria-hidden is removed after
close while content remains mounted.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/core/src/Dialog/Dialog.test.ts`:
- Around line 121-124: The test currently only verifies the initial state;
update 'should not apply aria-hidden to body when closed' to exercise the
open→close cycle: render/open the Dialog (use the component's present/open API
or simulate the trigger to set present=true), await nextTick, simulate closing
via Escape (dispatch a KeyboardEvent or use the library's close method), await
nextTick again, then assert document.body.getAttribute('aria-hidden') is null;
this will exercise useHideOthers' present-gated behavior and confirm aria-hidden
is removed after close while content remains mounted.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 67453a23-6bee-4ca2-9e43-b4cc6aff568c

📥 Commits

Reviewing files that changed from the base of the PR and between 4c52c59 and 84622de.

📒 Files selected for processing (2)
  • packages/core/src/Dialog/Dialog.test.ts
  • packages/core/src/Dialog/DialogOverlayImpl.vue

The focus-restoration watcher only lived in DialogContentModal, so a
non-modal dialog with unmountOnHide=false never returned focus to the
trigger on close (close-auto-focus doesn't fire while mounted). Mirror
the watcher in DialogContentNonModal, respecting interact-outside, and
document why the watcher exists in both.
Assert the rest of the page stays accessible after a full open/close
cycle (content remains mounted) rather than just checking initial state.
…hile mounted

When `disableOutsidePointerEvents` toggles true→false without unmounting
(the unmountOnHide=false case), the watchEffect cleanup read the prop
reactively and saw the new false value, skipping the body pointer-events
restore and leaving the page frozen. Capture the value at run time for
cleanup, and read the layer set size via toRaw to avoid self-retriggering.
With unmountOnHide=false the FocusScope stays mounted across open/close,
so the mount auto-focus (keyed off physical mount) never re-fires on
reopen and focus never enters the content. Watch the trapped false->true
transition and re-run the mount auto-focus. The default unmount-on-close
path mounts already trapped, so this transition never happens there and
behavior is unchanged.
When a force-mounted scope (`unmountOnHide: false`) becomes trapped on
reopen, the `v-show` visibility can apply a frame after `trapped` flips,
so the first focus attempt runs while the container is still
`display: none` and no-ops. Retry the focus move on the next frame
without re-dispatching the auto-focus event.
@benjamincanac benjamincanac marked this pull request as draft May 29, 2026 13:33
@benjamincanac benjamincanac marked this pull request as ready for review May 29, 2026 13:53
…opes

A force-mounted scope (Dialog `unmountOnHide: false`) physically mounts
once while hidden via `v-show`, so keying auto-focus off physical mount
fired `mountAutoFocus`/`openAutoFocus` and stole focus into a closed
dialog, and never re-focused non-modal content on open (it isn't
trapped). Gate the mount-time auto-focus on visibility and re-run it on
the hidden -> visible transition via a MutationObserver, covering first
open and reopen for both modal and non-modal. Replaces the trapped
watcher + rAF retry, which only handled the modal case.
@zernonia

zernonia commented Jun 10, 2026

Copy link
Copy Markdown
Member

Thanks for the PR @benjamincanac ! Main concerns are in the two shared primitives it touches.

🔴 Major

1. DismissableLayer change is stale / conflicts with #2674. This branch forked before 034d20edd (#2674) landed on v2, which already rewrote this to a watch and solves the same "restore body pointer-events when toggled off while mounted" problem. The toRaw/watchEffect change will merge-conflict and is redundant → rebase onto v2 and drop it (keep the new tests).

2. New FocusScope MutationObserver is added to every consumer. Gated only on isClient, not on force-mount, so Popover/Menu/Select/Combobox/Toast all now get a style/class observer calling getComputedStyle().display on every mutation — forced reflow during animations, plus possible focus-stealing on display: none → visible transitions. → Scope it to the force-mounted-hidden case.

🟡 Medium

  • display: none sniffing is brittle as a primitive-level contract (breaks for visibility/opacity/hidden). Prefer an explicit "visible" signal from the consumer.
  • Duplicated trigger-refocus watch in both DialogContentModal/NonModal, now overlapping FocusScope's new re-focus → double-focus/race risk on rapid toggling.
  • v-show vs hidden="until-found" — existing unmountOnHide primitives (Collapsible/Tabs/Accordion) use until-found, which is more SEO-discoverable than display: none. Worth a note on the choice.

🟢 Looks good

aria-hidden gating, scroll-lock watch (valid — useBodyScrollLock returns a writable ref), present-prop stripping, typo fix. Minor: setTimeout(r, 10) magic waits in tests are a CI-flakiness risk.

Recommendation: resolve #1 (rebase + drop) and #2 (scope the observer) before merge; 3–5 worth addressing or documenting.

…n-hide

# Conflicts:
#	packages/core/src/Dialog/Dialog.test.ts
#	packages/core/src/Dialog/DialogContentModal.vue
#	packages/core/src/DismissableLayer/DismissableLayer.test.ts
#	packages/core/src/DismissableLayer/DismissableLayer.vue
…nmountOnHide

When `unmountOnHide` is `false` the dialog content stays mounted while
closed, but FocusScope and DismissableLayer keyed their global stack
membership off physical mount. With more than one keep-mounted dialog on
a page, the later-mounted hidden layer was treated as the topmost layer
(swallowing Escape and outside interactions meant for the open dialog)
and its hidden scope permanently paused the open dialog's focus trap.

- FocusScope: replace `display: none` sniffing with an explicit
  `present` prop; stack membership and mount auto-focus now follow
  `present` transitions instead of mount. Drop the per-consumer
  MutationObserver.
- DismissableLayer: add an internal `present` prop (kept out of the
  public `DismissableLayerProps`); layer-stack membership, body
  pointer-events locking and outside-interaction events now follow
  presence, so hidden layers no longer swallow dismissal or emit
  `interactOutside` while closed.
- Dialog: forward `present` through DialogContentImpl to both
  primitives.
- Tests: regression coverage for the two-dialog scenario; replace
  `setTimeout` magic waits with `vi.waitFor`.
@benjamincanac

Copy link
Copy Markdown
Contributor Author

Thanks for the review @zernonia! Everything should be addressed in the latest push:

  1. Rebased on v2, the toRaw/watchEffect change is gone and the regression tests are kept (they pass against your [Bug]: Dropdown inside dialog strips pointer-events style from body #2674 implementation). DismissableLayer is touched again but for a new reason, see below.
  2. Removed the MutationObserver entirely. FocusScope now takes an explicit present prop, consumers that don't pass it are unaffected.
  3. No more display: none sniffing, replaced by the explicit present signal as you suggested.
  4. No overlap in practice: the Dialog watches only fire on close (restore trigger focus) while the FocusScope watcher only fires on open (auto-focus) and bails if focus already landed inside. There is a test asserting openAutoFocus fires exactly once per open.
  5. v-show is deliberate: hidden="until-found" reveals content on beforematch without the overlay, focus trap or scroll lock engaging, which is fine for inline content (Collapsible/Tabs) but broken for overlay primitives. display: none content is still indexed as long as it's in the DOM, which is the actual SEO requirement from [Feature]: Dont remove Dialog from DOM (use v-show instead of v-if) #1727.

While testing in a real browser I found that both global stacks were keyed on mount: with two unmountOnHide: false dialogs on one page, the hidden one swallowed Escape / outside dismiss for the open one, and its hidden FocusScope paused the open modal's focus trap. Both primitives now key stack membership on present (same plumbing as 2., one level deeper), with regression tests.

@zernonia

Copy link
Copy Markdown
Member

Re-review (after 3ac782e)

Both 🔴 blockers are resolved — and well. Verified locally on f870a8623: Dialog + DismissableLayer + FocusScope (50 passed), and the other shared-primitive consumers Popover/Menu/Select/Combobox/Toast (89 passed), so the default path is confirmed unaffected.

✅ Resolved

  • Feat: Accordion #1 DismissableLayer — rebased onto v2 ([Bug]: Dropdown inside dialog strips pointer-events style from body #2674 now in base) and the stale toRaw/watchEffect change is gone. The new internal present prop gating layer-stack membership + outside-interaction handling is the right model, and keeping stack membership in a separate watch from the pointer-events watch (so a disableOutsidePointerEvents toggle never re-orders the stack) is a nice touch.
  • Feat: Alert Dialog #2 FocusScope — the unconditional MutationObserver/getComputedStyle is replaced by an explicit present prop (default true). Default consumers never enter the new watch(() => props.present) path, so no per-instance observer or forced reflow. This also resolves medium Feat: Aspect Ratio #3 (no more display:none sniffing — driven off an explicit signal). The dependency-tracking note (reading props.present after await so the mount effect doesn't track it) is correct.
  • medium Feat: Avatar #4 — the close-path focus restore is now solely the Dialog modal/non-modal watch(present); FocusScope's present-watch only removes from the stack on close and re-focuses on open, so there's no double focus-restore. Stack add/remove is balanced across mount/open/close/unmount.

🟡 Minor (non-blocking)

  1. Escape isn't gated on present. pointerDownOutside/focusOutside now early-return on !props.present, but the onKeyStroke('Escape') handler doesn't. A hidden layer's index is -1 so it's mostly neutralized, but when no layer is visible (layers.size === 0), index === size - 1 becomes -1 === -1 → true, so a closed-but-mounted Dialog still emits escapeKeyDown (and a no-op dismiss) on Escape. Worth gating Escape on props.present for consistency.
  2. Redundant unmount cleanup. The trailing watchEffect that deletes from layers/layersWithOutsidePointerEventsDisabled on unmount now overlaps the two present-gated onCleanups. Harmless (idempotent), but could be dropped to reduce surface.
  3. v-show vs hidden="until-found" (prior Feat: Checkbox #5) still stands — existing unmountOnHide primitives use until-found, which is more discoverable for the SEO case cited. Fine to keep v-show if intentional; just worth a one-line rationale.

Nice iteration — happy to see this land once the Escape nit is considered.

A layer kept mounted while hidden (e.g. a Dialog with `unmountOnHide: false`)
is out of the layer stack, so its `index` is `-1`. When no layer is visible
(`size === 0`), `-1 === size - 1` made the hidden layer look like the highest
layer and emit `escapeKeyDown` / `dismiss` on Escape for an already-closed
dialog. Gate the Escape handler on `props.present`, consistent with the
existing `pointerDownOutside` / `focusOutside` guards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@zernonia

Copy link
Copy Markdown
Member

Pushed 515a0b2 directly to the branch addressing minor #1 — gates the Escape handler on props.present (consistent with the pointerDownOutside/focusOutside guards) so a hidden, mounted layer no longer emits escapeKeyDown/dismiss when no layer is visible. Added two regression tests; full DismissableLayer + Dialog/AlertDialog/Popover/Menu suites pass locally. Feel free to squash/reword. Left #2 (redundant unmount cleanup) and #3 (v-show vs until-found) for you to decide on.

@zernonia zernonia merged commit eb746ad into unovue:v2 Jun 15, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Dont remove Dialog from DOM (use v-show instead of v-if)

3 participants